Skip to content

Go 并发模式:把任务分开以后,怎样一起收尾 ​

worker pool、fan-out、pipeline 经常一起出现在同一段代码里。它们不是互斥的架构选项,而是从不同角度描述任务怎么分配、结果怎么流动。真正需要先决定的是:最多同时做多少事,任务失败后谁停下,下游不再读时上游怎样退出。

这篇文章围绕一批整数求平方展开:一个生产者发任务,两个 worker 计算,一个收集者读取结果。随后在同一个生命周期框架里解释信号量、发布订阅和 errgroup。示例使用 Go 1.27.2,errgroup 固定为独立模块 golang.org/x/sync v0.16.0。[1][2]

这些模式分别管什么 ​

模式分配或连接关系主要解决的问题必须额外决定的事
worker pool固定数量 worker 从任务队列取活给活跃处理者设上限队列容量、停止接单、排空还是丢弃
fan-out一个输入分给多个处理者让独立任务并行处理单条输入通常只给一个接收者;结果顺序可能改变
fan-in多个结果来源汇到一个出口统一消费和结束通知所有发送者结束后谁关闭输出
pipeline多个阶段用 channel 连接把输入、变换、输出组合起来每一段背压、错误和提前退出如何传播
信号量进入资源区前获取名额,离开时归还限制并发资源占用在哪里排队,获取失败后如何收尾
发布订阅每个订阅者有自己的接收路径同一事件送给多个接收者慢订阅者阻塞、丢弃还是断开
errgroup一组函数属于同一项工作等待、首个错误与协同取消下游是否配合取消,错误是否需要全部收集

这些名称表达设计结构,Go 并没有一个内建的 pipeline 类型。官方 pipeline 文章也是按阶段、channel 和 goroutine 的关系定义它们。[1:1]

一份完整的小流水线 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    V[输入切片] --> P[一个生产者]
    P --> J[jobs:无缓冲]
    J --> W1[worker 1:求平方]
    J --> W2[worker 2:求平方]
    W1 --> R[results:无缓冲]
    W2 --> R
    R --> C[单一收集者]
    W1 -. 结束计数 .-> X[协调者:等所有 worker]
    W2 -. 结束计数 .-> X
    X -. 最后关闭 .-> R
    K[共同 context] -. 取消输入与输出等待 .-> P
    K -. 取消 .-> W1
    K -. 取消 .-> W2
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    V[输入切片] --> P[一个生产者]
    P --> J[jobs:无缓冲]
    J --> W1[worker 1:求平方]
    J --> W2[worker 2:求平方]
    W1 --> R[results:无缓冲]
    W2 --> R
    R --> C[单一收集者]
    W1 -. 结束计数 .-> X[协调者:等所有 worker]
    W2 -. 结束计数 .-> X
    X -. 最后关闭 .-> R
    K[共同 context] -. 取消输入与输出等待 .-> P
    K -. 取消 .-> W1
    K -. 取消 .-> W2

同一个 jobs 被两个 worker 接收,是 fan-out:一个任务被一个 worker 取走,不会自动复制给两人。两个 worker 都发到 results,是共享输出形式的 fan-in;也可以让每个 worker 有独立输出,再写一个 merge 阶段。整个“输入、计算、收集”的连接又是一条 pipeline。[1:2][3]

文末 Squares 的调用很普通:

go
out, err := Squares(context.Background(), []int{1, 2, 3, 4}, 2)
fmt.Println(out, err)

本次实际输出:

text
[1 4 9 16] <nil>

这个稳定顺序是函数最后显式排序得到的。worker 完成顺序没有保证;如果业务要求与输入逐项对应,应该给每个任务附上下标,结果写回对应位置,而不是把结果按值排序。平方示例能排序,是因为这里仅要求结果集合。[4]

先确定关闭者,再启动工作 ​

资源谁写/发谁关闭或等待为什么没有发送撞上关闭
jobs唯一生产者生产者 defer close最后一次发送后才返回
results所有 worker协调者等 producers.Wait 后关闭Wait 表明所有 worker 已不再发送
共同 context调用方可取消,Squares 也持有 cancelSquares 返回前 cancel是退出请求,不负责关闭数据 channel
全部自建 goroutine生产者、worker、关闭协调者all.Wait函数返回前把自己启动的工作等齐

代码使用两个 WaitGroup:producers 只数结果发送者,用来判断何时能安全关闭 results;all 数本函数启动的所有 goroutine,用来确认生命周期已收束。把两者混成一个计数,如果“关闭 results 的协调者”也在同一个它自己等待的计数里,就会等到自己,形成死锁。[5]

背压从接收者一路传回生产者 ​

本例两个 channel 都无缓冲。收集者忙,worker 的发送会等待;worker 不能继续取下一个任务,生产者的发送也会等待。这条等待链把输入速度约束在下游能承受的范围内。

缓冲能吸收短时波动,但必须决定容量含义。队列里一个元素如果是指向大请求的指针,仅数“有 1000 个槽位”还看不出实际内存占用。固定 worker 数也不等于整个系统有界:如果任务来源先把百万个请求全读进切片,内存仍可增长;如果在每个请求里先启动 goroutine 再等名额,等待 goroutine 的数量仍可能无限。[1:3][3:1]

本例输入已经是一个有限切片,输出也累积在切片里,所以适合演示一批有限工作,并非无限流方案。无限或很大的输入通常需要逐条读取与逐条输出,而不是全量收集。

提前返回最容易漏掉的是发送端 ​

错误路径经常长这样:消费者只需要第一个结果,拿到后就返回;剩下的 worker 还在向 results 发送,永远等不到接收者。给 results 增加一个任意大小的缓冲,只能在刚好装得下时掩盖问题。[1:4]

本例在生产者发送 jobs 和 worker 发送 results 的地方,都选择“发送成功,或者取消”:

go
select {
case results <- n * n:
case <-ctx.Done():
    return
}

接收任务时也监听同一个 ctx。这样父级取消后,等待输入和等待输出都有退出路径。Squares 的收集者会一直读到 results 关闭,再等 all;不会在仍有发送者存在时直接抛下输出。

发生什么这个示例怎样收尾需要知道的边界
输入处理完关闭 jobs,worker 退出,协调者关 results,收集结束无外部 I/O,平方计算会迅速返回
父级取消每个 channel 阻塞点都能观察 ctx,最后等待所有 goroutine已拿到的任务可能继续算一次;select 不给取消优先权
worker 数为 0 或负数启动前直接返回错误避免生产者没有接收者
输入为空生产者直接关闭 jobs,后续自然结束不要求特殊的 sleep 或轮询

本例最后检查 parent.Err(),所以父级已经取消时返回取消错误,丢弃部分结果。这是本函数选择的接口语义;业务如果要返回部分成功结果,要把“结果是否完整”一起表达,不能悄悄当作全量成功。

限并发与限速:先分清名额代表什么 ​

容量为 N 的 channel 或 semaphore.Weighted 都能用作并发名额。一个任务进入前获取,完成时释放,相当于限制同时占着某种资源的工作数量。[6]

限制目标控制量适合的机制一个常见误解
同时最多 20 个下游请求当前在途数量固定 worker、errgroup.SetLimit、信号量20 并发并不等于每秒 20 次
同时最多占用 100 单位内存预算活跃任务的权重总和加权信号量一个任务可能占多个名额
每秒平均 50 次、允许少量突发随时间补充的令牌令牌桶,如 x/time/rate队列容量不会自动变成速率限制

如果每个任务只需一毫秒,20 并发的完成速率可能很高;每个任务要一秒时又完全不同。因此不要把 semaphore 命名为“每秒速率限制器”。x/time/rate 是另一个模块,本篇只给出机制边界,没有安装或测试该包。[7]

获取名额最好放在启动 goroutine 之前,这样提交者本身承担背压。已经成功获取后要配对释放;获取被取消而失败则不能 Release。实际工作也要使用 ctx,信号量只让“等名额”可取消,不能结束已经进入临界资源区的任务。加权版需先校验 0 < weight <= limit;文末的工具选型专文有完整加权实现。这里不是依赖链接替代原理:关键就是让所有活跃任务的权重之和不超过预算。[6:1]

发布订阅:同一事件要送多份,慢订阅者要有政策 ​

多个 worker 读同一个 channel 会分摊任务,不会各得一份。要广播,发布者需要向每个订阅者自己的队列发送。于是一个新的问题出现了:其中一个人一直不读,要不要让其他人也收不到后面的事件?

慢订阅者策略好处代价与适用边界
阻塞等待在进程正常运行且消费持续推进的前提下,不主动丢事件一个慢订阅者可拖住整个发布循环
缓冲满就丢本次事件发布者和其他订阅者继续前进接收方必须能容忍缺失,如状态刷新通知
断开慢订阅者避免长期占住资源需要重连、重放或重新拉取快照
写入持久消息系统能进一步设计确认、重试、恢复增加服务、存储与一致性成本;不再只是内存 channel

下面实现选择“丢本次事件”,并把丢弃数量返回给调用者:

go
func Publish(subs []chan int, value int) (dropped int) {
    for _, sub := range subs {
        select {
        case sub <- value:
        default:
            dropped++
        }
    }
    return
}

两个订阅队列容量都为 1。第一次发布 1 后,只让 fast 取走它,再发布 2。本次输出:

text
first dropped: 0
fast: 1
second dropped: 1
remaining: 2 1

slow 还装着 1,因此第二条丢给了 slow,fast 收到了 2。这里没有“保证送达”的暗示,输出直接把丢弃结果暴露出来。该教学 broker 由一个调用方独占管理订阅列表、发布和关闭;不能让另一个 goroutine 随意关闭其中的 channel,否则 select 也挡不住发送到已关闭 channel 的 panic。增加并发订阅/退订时,需要让同一个 broker 循环拥有这些动作,或另设完整同步协议。[8][4:1]

errgroup:一个分支失败,让其余分支知道 ​

WaitGroup 能等齐,却不收错误。errgroup 把一组返回 error 的函数放在一起,Wait 等所有函数返回,再给出第一个非 nil 错误;WithContext 返回的派生 ctx 在首个错误出现时取消,也在 Wait 返回时取消。[2:1]

go
g, ctx := errgroup.WithContext(context.Background())
started := make(chan struct{})
g.Go(func() error { close(started); <-ctx.Done(); return ctx.Err() })
g.Go(func() error { <-started; return errors.New("stage failed") })
fmt.Println("errgroup:", g.Wait())

本次输出:

text
errgroup: stage failed

第一个分支先开始等待,第二个分支报业务错误,于是第一个收到取消并返回。errgroup 保存的是触发取消的那个错误,而不是随后产生的 canceled。它不会把所有错误自动 Join,也不会把 panic 自动变成 error。[2:2]

把 errgroup 放进 pipeline 时要小心等待环:如果 worker 的退出依赖消费者继续读,而消费者在开始读之前先 g.Wait(),双方仍然会卡住。错误聚合工具不会替你改变数据流。输出的关闭协调也必须等“所有输出发送者”结束,而不能提前关闭。

如果只需要处理一个有限 slice,可用 errgroup.SetLimit 限活跃任务,少写一个任务队列。若输入是连续流、需要独立的排队容量、优先级或拒绝策略,显式 worker pool 往往更清楚。SetLimit 会让提交的 Go 调用等待名额,这次等待并没有 context 参数;任务还在运行时也不能修改 limit。复杂入队取消需求可选择固定 worker 或带 ctx 的 semaphore.Acquire。[2:3]

依据与本次验证 ​

本次在 Go 1.27.2、Linux/amd64 实际运行有限 pipeline、丢弃型发布订阅和 errgroup 失败取消。测试覆盖正常计算、预先取消、空输入、非法 worker 数、慢订阅者丢弃及分支失败;go test -race -count=1 ./...、go vet ./... 通过。没有对无限输入、真实网络或持久消息系统做实验,也没有把平方任务的吞吐代表业务负载。

完整代码与复现方法 ​

以同一目录为根,按下面标出的相对路径保存文件。使用 Go 1.27.2 运行:

sh
go run .
go test -race -count=1 ./...
go vet ./...

依赖固定为 golang.org/x/sync v0.16.0。首次运行需要该版本已在模块缓存中,或能够访问 Go 模块代理;它是独立模块,不随 Go 1.27.2 自动更新。

go.mod ​

go.mod
module example.com/go-articles/05_patterns

go 1.25.0

require golang.org/x/sync v0.16.0

go.sum ​

text
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=

main.go ​

代码块 · 101 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"errors"
	"fmt"
	"golang.org/x/sync/errgroup"
	"slices"
	"sync"
)

// Squares owns every goroutine it starts and returns only after all exit.
func Squares(parent context.Context, values []int, workers int) ([]int, error) {
	if workers <= 0 {
		return nil, errors.New("workers must be positive")
	}
	ctx, cancel := context.WithCancel(parent)
	defer cancel()
	jobs := make(chan int)
	results := make(chan int)
	var all, producers sync.WaitGroup
	all.Add(1)
	go func() {
		defer all.Done()
		defer close(jobs)
		for _, n := range values {
			select {
			case jobs <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	for range workers {
		producers.Add(1)
		all.Add(1)
		go func() {
			defer producers.Done()
			defer all.Done()
			for {
				select {
				case <-ctx.Done():
					return
				case n, ok := <-jobs:
					if !ok {
						return
					}
					select {
					case results <- n * n:
					case <-ctx.Done():
						return
					}
				}
			}
		}()
	}
	all.Add(1)
	go func() { defer all.Done(); producers.Wait(); close(results) }()
	var out []int
	for n := range results {
		out = append(out, n)
	}
	all.Wait()
	if err := parent.Err(); err != nil {
		return nil, err
	}
	slices.Sort(out)
	return out, nil
}

// The broker is single-owner: only its caller touches subscribers and closes them.
func Publish(subs []chan int, value int) (dropped int) {
	for _, sub := range subs {
		select {
		case sub <- value:
		default:
			dropped++
		}
	}
	return
}
func CancelPeers() error {
	g, ctx := errgroup.WithContext(context.Background())
	started := make(chan struct{})
	g.Go(func() error { close(started); <-ctx.Done(); return ctx.Err() })
	g.Go(func() error { <-started; return errors.New("stage failed") })
	return g.Wait()
}
func main() {
	out, err := Squares(context.Background(), []int{1, 2, 3, 4}, 2)
	fmt.Println(out, err)
	fast, slow := make(chan int, 1), make(chan int, 1)
	subs := []chan int{fast, slow}
	fmt.Println("first dropped:", Publish(subs, 1))
	fmt.Println("fast:", <-fast)
	fmt.Println("second dropped:", Publish(subs, 2))
	close(fast)
	close(slow)
	fmt.Println("remaining:", <-fast, <-slow)
	fmt.Println("errgroup:", CancelPeers())
}

main_test.go ​

代码块 · 52 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"errors"
	"slices"
	"testing"
)

func TestSquares(t *testing.T) {
	got, err := Squares(context.Background(), []int{1, 2, 3, 4}, 2)
	if err != nil || !slices.Equal(got, []int{1, 4, 9, 16}) {
		t.Fatal(got, err)
	}
}
func TestAlreadyCanceled(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	_, err := Squares(ctx, make([]int, 100), 2)
	if !errors.Is(err, context.Canceled) {
		t.Fatal(err)
	}
}
func TestEmptyAndInvalid(t *testing.T) {
	if out, err := Squares(context.Background(), nil, 3); err != nil || len(out) != 0 {
		t.Fatal(out, err)
	}
	if _, err := Squares(context.Background(), nil, 0); err == nil {
		t.Fatal("expected error")
	}
}

func TestPublishDropPolicy(t *testing.T) {
	a, b := make(chan int, 1), make(chan int, 1)
	if n := Publish([]chan int{a, b}, 7); n != 0 {
		t.Fatal(n)
	}
	<-a
	if n := Publish([]chan int{a, b}, 9); n != 1 {
		t.Fatal(n)
	}
	if x, y := <-a, <-b; x != 9 || y != 7 {
		t.Fatal(x, y)
	}
	close(a)
	close(b)
}
func TestCancelPeers(t *testing.T) {
	if e := CancelPeers(); e == nil || e.Error() != "stage failed" {
		t.Fatal(e)
	}
}

  1. Go 官方:Pipelines and cancellation,阶段定义、fan-out/fan-in、关闭与提前结束。本文使用自己的有限批处理和收尾接口。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. x/sync v0.16.0 errgroup,WithContext、Wait、Go、SetLimit。本次也读取了该缓存版本的完整 go doc。 ↩︎ ↩︎ ↩︎ ↩︎

  3. 规范:Channel types及接收操作。 ↩︎ ↩︎

  4. 规范:Select statements。 ↩︎ ↩︎

  5. sync.WaitGroup,Go 1.27.2。 ↩︎

  6. x/sync v0.16.0 semaphore,Acquire、Release 和权重限制。固定版本源码已在本次环境核对。 ↩︎ ↩︎

  7. golang.org/x/time/rate,令牌桶和 rate/burst 概念,仅用于与并发上限区别。 ↩︎

  8. 规范:Send statements。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准